Skip to content

Boost.Asio 定时器:周期任务与重设到期时间 ​

用 Boost.Asio 的 basic_waitable_timer(system_timer)做周期任务时有两个容易混淆的问题:handler 到底在什么时候被调用;运行中重设到期时间时,旧的等待会发生什么。本文先用官方文档把语义说清,再用一个可重设周期的定时器类把实际行为走查一遍(行为在 Boost 1.66 上实测,expires_after、async_wait 的语义沿用至今)。

handler 何时被调用 ​

async_wait 的文档给出了明确保证:每次调用 async_wait,提供的 handler 恰好被调用一次,触发时机有两种:

  1. 定时器到期,此时错误码为空(成功);
  2. 定时器被取消,此时 handler 收到错误码 boost::asio::error::operation_aborted。

取消的来源主要是 cancel() 和 expires_after()。expires_after 重新设定相对到期时间,同时取消该定时器上所有挂起的异步等待,返回值就是被取消的操作数;每个被取消的等待各回调一次,错误码都是 operation_aborted。一个边界情况:如果调用 expires_after 时定时器已经到期、handler 已投递或即将投递,则无法取消,这些 handler 收到的是表示等待成功的错误码。

可重设周期的定时器 ​

下面是一个完整示例:未启动时设定周期并启动首个等待;运行中再次调用则重设到期时间。周期重启的关键在 handler 里——正常到期(错误码为空)时重新 expires_after + async_wait 排下一轮。

cpp
#include <boost/asio.hpp>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <memory>
#include <thread>

class PeriodicTimer
{
public:
    ~PeriodicTimer()
    {
        io_.stop();
        run_thread_.join();
    }

    // 设定周期(秒);未启动时启动首个等待,运行中则重设到期时间
    void set_interval(std::uint64_t seconds)
    {
        interval_s_ = seconds;
        if (!running_)
        {
            timer_ = std::make_shared<boost::asio::system_timer>(
                io_, std::chrono::seconds(interval_s_));
            timer_->async_wait(
                [this](const boost::system::error_code& ec) { on_timer(ec); });
        }
        else
        {
            // 重设到期时间:挂起的等待被取消,返回取消数
            std::size_t cancelled =
                timer_->expires_after(std::chrono::seconds(interval_s_));
            std::cout << "set_interval: cancelled " << cancelled
                      << " pending wait(s)\n";
            timer_->async_wait(
                [this](const boost::system::error_code& ec) { on_timer(ec); });
        }
    }

    void run()
    {
        if (running_)
            return;
        running_ = true;
        std::thread th([this]() { io_.run(); });
        run_thread_.swap(th);
    }

private:
    void on_timer(const boost::system::error_code& ec)
    {
        if (!ec)  // 正常到期:排下一轮
        {
            std::cout << "timeout: " << interval_s_ << "s\n";
            timer_->expires_after(std::chrono::seconds(interval_s_));
            timer_->async_wait(
                [this](const boost::system::error_code& e) { on_timer(e); });
        }
        // ec 非空:等待被取消(重设到期时间等),不再排下一轮
    }

    boost::asio::io_context io_;
    std::shared_ptr<boost::asio::system_timer> timer_;
    std::uint64_t interval_s_ = 0;
    std::thread run_thread_;
    bool running_ = false;
};

int main()
{
    PeriodicTimer t;
    t.set_interval(3);
    t.run();
    std::this_thread::sleep_for(std::chrono::seconds(10));

    t.set_interval(4);  // 运行中重设:取消当前 3s 等待,改按 4s 触发
    std::this_thread::sleep_for(std::chrono::seconds(10));
}

运行行为 ​

一次实际运行的输出(略去时间戳):

text
set_interval: cancelled 0 pending wait(s)   ← 首次设置,无挂起等待
timeout: 3                                  ← 之后每 3 秒触发
timeout: 3
timeout: 3
set_interval: cancelled 1 pending wait(s)   ← 第 10 秒重设为 4s
timeout: 4                                  ← 旧等待被取消后,按 4s 周期触发
timeout: 4

对照输出可以把三个行为点看清楚:

  1. 周期触发链:handler 正常到期(ec 为空)→ expires_after + async_wait 排下一轮,形成周期;
  2. 运行中重设:expires_after 返回 1(取消了 1 个挂起等待)。被取消的旧 handler 仍会被调用一次,但 ec 为 operation_aborted,重启分支被跳过;新排入的等待按新周期触发;
  3. stop 之后没有回调:析构里 io_.stop() 之后再 join,挂起等待的 handler 不再被调用。

stop() 与挂起 handler 的去向 ​

第 3 点值得单独说明:io_context::stop() 的官方文档只说它让 run()、run_one() 等尽快返回、后续调用立即返回,并没有规定挂起 handler 的去向。实际行为是 stop 之后这些 handler 不会再被调用(上面的程序也印证了这一点:stop 后无任何回调)。

写代码时可以依赖这个行为做干净的关闭:先 stop() 再 join(),此时 handler 不再执行,也就不会在对象析构中途反过来访问 this。但要把「stop 后 handler 不跑」当作实现行为而不是文档保证来对待——涉及关闭顺序的代码,值得在自己的 Boost 版本上验证一次。

结语 ​

  • async_wait 的 handler 恰好被调用一次:到期收空错误码,取消收 operation_aborted;
  • expires_after 重设到期时间会取消全部挂起等待并返回取消数,配合 handler 里的重启分支即可实现可动态调整的周期任务;
  • io_context::stop() 之后挂起 handler 不再回调是实测行为,官方文档未作承诺,关闭顺序敏感的代码要自行验证。
最近更新

基于 VitePress 构建